package org.fastcatsearch.util; import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.htmlcleaner.CleanerProperties; import org.htmlcleaner.HtmlCleaner; import org.htmlcleaner.PrettyXmlSerializer; import org.htmlcleaner.TagNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; public class ReadabilityExtractor extends ContentExtractor{ private static Logger logger = LoggerFactory.getLogger(ReadabilityExtractor.class); //가능성이 없는 태그 id와 class private static Pattern unlikelyCandidates = Pattern.compile("combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter|facebook|me2day|yozm",Pattern.CASE_INSENSITIVE); //가능성이 높은 태그 id와 class private static Pattern okMaybeItsACandidate = Pattern.compile("and|article|body|column|main|shadow",Pattern.CASE_INSENSITIVE); //태그의 id/class 무게 측정시 사용 //무게를 가해주는 id나 class private static Pattern positive = Pattern.compile("article|body|content|entry|hentry|main|page|pagination|post|text|blog|story",Pattern.CASE_INSENSITIVE); //무게를 감해주는 id나 class private static Pattern negative = Pattern.compile("combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget",Pattern.CASE_INSENSITIVE); //문자열 블록유형 태그 private static Pattern divToPElements = Pattern.compile("<(a|blockquote|dl|div|img|ol|p|pre|table|ul)",Pattern.CASE_INSENSITIVE); // 미리 삭제할 태그 private static Pattern toRemove = Pattern.compile("(object|th|h1|button|iframe)",Pattern.CASE_INSENSITIVE); private static String blankKillReg = "\\s{2,}"; public String extract(String source){ StringBuilder result = new StringBuilder(); //html을 xml로 빠꾸어줌. htmlcleaner을 사용함. //1) 우선 script 태그를 제거한다. //2) CSS코드 삭제와 태크 매칭이 않된것을 보완한다. source = this.toXML(source); // org.w3c.dom tree 만들기. DocumentBuilderFactory factory=DocumentBuilderFactory.newInstance(); DocumentBuilder builder = null; Document document = null; try { builder = factory.newDocumentBuilder(); document = builder.parse(new ByteArrayInputStream(source.getBytes()));//sb.build(new ByteArrayInputStream(source.getBytes())); } catch (ParserConfigurationException e) { logger.error("",e); } catch (SAXException e) { logger.error("",e); } catch (IOException e) { logger.error("",e); } NodeList allElements = document.getElementsByTagName("*"); //점수 줄 대상 노드들.nodesToScore List<Node> nodesToScore = new ArrayList<Node>(); for (int i = 0; i < allElements.getLength(); i++) { Node n = allElements.item(i); String unlikelyMatchString = ""; Node cn = n.getAttributes().getNamedItem("class"); if(cn != null) unlikelyMatchString += cn.getNodeValue(); Node in = n.getAttributes().getNamedItem("id"); if(in != null) unlikelyMatchString += in.getNodeValue(); //3) 내용태그일 가능성이 없는 태그들을 처리대상에서 제거한다. if (toRemove.matcher(unlikelyMatchString).find()) { // n.setTextContent(""); n.getParentNode().removeChild(n); continue; } if (unlikelyCandidates.matcher(unlikelyMatchString).find() && !okMaybeItsACandidate.matcher(unlikelyMatchString).find() && !"body".equalsIgnoreCase(n.getNodeName())) { // n.setTextContent(""); n.getParentNode().removeChild(n); continue; } //4) p,td,pre태그를 수집하고 하위에 text블록유형태그가 없는 div태그를 수집하고 하위에 text블록유형태그가 있는 div는 이 text블록유형태그들을 p로 바꿔준다. if ("P".equalsIgnoreCase(n.getNodeName()) || "TD".equalsIgnoreCase(n.getNodeName()) ||"PRE".equalsIgnoreCase(n.getNodeName())) { nodesToScore.add(n); } if ("DIV".equalsIgnoreCase(n.getNodeName())) { String nodeStr = nodeToString(n); if (!divToPElements.matcher(nodeStr).find()) { nodesToScore.add(n); } else { NodeList childs = n.getChildNodes(); for (int j = 0; j < childs.getLength(); j++) { Node childNode = childs.item(j); String nodeName = childNode.getNodeName(); if ("#text".equals(nodeName)) {// Node.TEXT_NODE Element newElem = document.createElement("p"); newElem.setTextContent(childNode.getTextContent()); if(childNode.getParentNode() != null) childNode.getParentNode().replaceChild(newElem, childNode); } } } } } /** * 5) nodesToScore를 loop 돌면서 각 태그에 점수를 준다. * 1. 태그내 text의 길이가 25보다 짧으면 점수를 주지않는다. * 2. 기본적으로 1점을 준다. * 3. 콤마개수만큼 점수를 더한다. * 4. text길이 100 마다 1점씩 더하는데 최고 3점까지 더할수 있다. * 5. 해당 노드의 점수를 부모노드에 더해주고 증조부모노드에 점수의 절반을 더해준다. * 6. 점수를 더해주기전에 부모노드와 증조부노드를 FastcatDomNode로 초기화해준다. 초기화는 점수를 기본인 0으로 시작해서 태그에 따라 점수를 더하고 감한다. * 7. 마지막으로 부모노드와 증조부모노드만 후보리스트에 저장한다. **/ List<FastcatSearchDomNode> candidates = new ArrayList<FastcatSearchDomNode>(); for (int j = 0; j < nodesToScore.size(); j++) { Node nn = nodesToScore.get(j); Node parentNode = nn.getParentNode(); FastcatSearchDomNode pNode = null; Node grandParentNode = parentNode != null ? parentNode.getParentNode() : null; FastcatSearchDomNode gNode = null; String nodeText = nn.getTextContent().trim(); if (parentNode == null) { continue; } if (nodeText==null || nodeText.length() < 25) { continue; } pNode = initializeNode(parentNode); candidates.add(pNode); if(grandParentNode != null) { gNode = initializeNode(grandParentNode); candidates.add(gNode); } int contentScore = 0; contentScore += 1; contentScore += nodeText.split(",").length; contentScore += Math.min(Math.floor(nodeText.length() / 100), 3); pNode.setContentScore(pNode.getContentScore() + contentScore); if(gNode!=null) gNode.setContentScore(gNode.getContentScore() + contentScore/2); } //6) 후보리스트에서 점수가 제일 높은 노드를 찾는다.못 찾으면 그냥 body를 사용한다. FastcatSearchDomNode topCandidate = null; for (int j = 0; j <candidates.size(); j++) { FastcatSearchDomNode fdn = candidates.get(j); fdn.setContentScore(fdn.getContentScore()*(1-getLinkDensity((Element)fdn.getNode()))); if (topCandidate == null || fdn.getContentScore() > topCandidate.getContentScore()) { topCandidate = fdn; } } if (topCandidate == null) { Element newElem = document.createElement("DIV"); Node bodyNode = document.getElementsByTagName("body").item(0);//document.getFirstChild(); if (bodyNode == null) { bodyNode = document.getFirstChild(); } newElem.setTextContent(bodyNode.getTextContent()); bodyNode.getParentNode().replaceChild(newElem, bodyNode); topCandidate = initializeNode(newElem); } /** * 7) 점수가 제일 높은 노드의 부모노드의 자식노드들(sibling)을 loop돌면서 본문으로 택할지 판단한다. * 1. threshold를 제일 높은 점수의 20% 와 10 사이에서 높은 것으로 정한다. * 2. 점수가 제일 높은 노드는 무조건 선택한다. * 3. 노드의 class 이름이 점수가 제일 높은 노드랑 같은 노드는 보너스점수를 주는데 보너스점수를 제일 높은 점수의 20%로 준다.이 보너스점수와 해당 노드의 기존 점수의 합이 한계점보다 높거나 같으면 택한다. **/ double siblingScoreThreshold = Math.max(10, topCandidate.getContentScore() * 0.2); NodeList siblingNodes = topCandidate.getNode().getParentNode().getChildNodes(); for (int j = 0; j < siblingNodes.getLength(); j++) { Node siblingNode = siblingNodes.item(j); boolean append = false; if(siblingNode == topCandidate.getNode()) { append = true; } double contentBonus = 0; Node siblingNodeClassName = siblingNode.getAttributes() != null?siblingNode.getAttributes().getNamedItem("class"):null; String classNameSibling = ""; if(siblingNodeClassName != null) classNameSibling = siblingNodeClassName.getNodeValue(); Node topNodeClassName = topCandidate.getNode().getAttributes().getNamedItem("class"); String classNameTop = ""; if(topNodeClassName != null) classNameTop = topNodeClassName.getNodeValue(); if(classNameTop.equalsIgnoreCase(classNameSibling) && !"".equals(classNameTop)) { contentBonus += (topCandidate.getContentScore() * 0.2); } if((initializeNode(siblingNode).getContentScore()+contentBonus) >= siblingScoreThreshold) { append = true; } if(append) { //선택된 노드 후처리. if (siblingNode instanceof Element) { Element e = (Element)siblingNode; //선택된 노드의 모든 하위 노드들을 loop돌면서 이 노드들의 부모노드의 linkDensity가 0.7보다 높은 노드는 광고나 무의미한 링크일 가능성이 높기때문에 삭제한다. //선택된 노드의 모든 하위 노드들중에서 h2,h3인 태그가 하나만 있으면 제거한다. 하나면 광고 타이틀이나 댓글타이틀일 가능성이 높기 때문이다. NodeList nl = e.getElementsByTagName("*"); for (int i = 0; i < nl.getLength(); i++) { Element elm = (Element)nl.item(i); Node pNode = elm.getParentNode(); double linkDensity = getLinkDensity((Element)pNode); if (linkDensity >= 0.7) { pNode.removeChild(elm); } else{ if(e.getElementsByTagName("h3").getLength() == 1) { pNode.removeChild(elm); }else if(e.getElementsByTagName("h2").getLength() == 1) { pNode.removeChild(elm); } } } } String finalStr = siblingNode.getTextContent().replaceAll(blankKillReg, " ").trim(); result.append(finalStr + "\n"); } } return result.toString(); } /** * js라이브러리중 선택된 노드 후처리중 사용했던 메소드. * 효과적이지 못한것같아서 임시 버림. * Clean an element of all tags of type "tag" if they look fishy. * "Fishy" is an algorithm based on content length, classnames, link density, number of images & embeds, etc. * @param node * @param append */ // private void cleanConditionally(Node node) { // Node parent = node.getParentNode(); // Element e = (Element)node; // int weight = getClassWeight(node); // double contentScore = initializeNode(node).getContentScore(); // if (weight+contentScore < 0) { // if (parent != null) { // parent.removeChild(node); // } // }else if(node.getTextContent().split(",").length < 10){ // /** // * If there are not very many commas, and the number of // * non-paragraph elements is more than paragraphs or other ominous signs, remove the element. // **/ // // int pLength = e.getElementsByTagName("p").getLength(); // int imgLength = e.getElementsByTagName("img").getLength(); // int liLength = e.getElementsByTagName("li").getLength()-100; // int inputLength = e.getElementsByTagName("input").getLength(); // int embedCount = e.getElementsByTagName("embed").getLength(); // double linkDensity = getLinkDensity(e); // int contentLength = node.getTextContent().length(); // if ( imgLength > pLength ) { // if (parent != null) { // parent.removeChild(node); // } // } else if(liLength > pLength && !"ul".equalsIgnoreCase(e.getTagName()) && !"ol".equalsIgnoreCase(e.getTagName())) { // if (parent != null) { // parent.removeChild(node); // } // } else if( inputLength > Math.floor(pLength/3) ) { // if (parent != null) { // parent.removeChild(node); // } // } else if(contentLength < 25 && (imgLength == 0 || imgLength > 2) ) { // if (parent != null) { // parent.removeChild(node); // } // } else if(weight < 25 && linkDensity > 0.2) { // if (parent != null) { // parent.removeChild(node); // } // } else if(weight >= 25 && linkDensity > 0.5) { // if (parent != null) { // parent.removeChild(e); // } // } else if((embedCount == 1 && contentLength < 75) || embedCount > 1) { // if (parent != null) { // parent.removeChild(node); // } // } // // } // } /** * text블록에서 링크걸린 text의 비율 계산하는 메소드. * @param Element * @return number (float) **/ private double getLinkDensity(Element e) { NodeList links = e.getElementsByTagName("a"); String textContent = e.getTextContent().replaceAll(blankKillReg, "").trim(); int textLength = textContent.length(); if (textLength <= 0) { return 1; } int linkLength = 0; for (int i = 0; i < links.getLength(); i++) { String linkContent = links.item(i).getTextContent().replaceAll(blankKillReg, ""); linkLength += linkContent.trim().length(); } return (double)linkLength / textLength; } /** * org.w3c.dom 의 Node를 점수속성이 있는 FastcatDomNode로 초기화해주는 메소드. * @param Element * @return FastcatDomNode **/ private FastcatSearchDomNode initializeNode(Node node) { FastcatSearchDomNode fastNode = new FastcatSearchDomNode(); int contentScore = 0; if ("DIV".equalsIgnoreCase(node.getNodeName())) { contentScore += 5; } else if("PRE".equalsIgnoreCase(node.getNodeName())){ } else if("TD".equalsIgnoreCase(node.getNodeName())){ } else if("BLOCKQUOTE".equalsIgnoreCase(node.getNodeName())){ contentScore += 3; } else if("ADDRESS".equalsIgnoreCase(node.getNodeName())){ } else if("OL".equalsIgnoreCase(node.getNodeName())){ } else if("UL".equalsIgnoreCase(node.getNodeName())){ } else if("DL".equalsIgnoreCase(node.getNodeName())){ } else if("DD".equalsIgnoreCase(node.getNodeName())){ } else if("DT".equalsIgnoreCase(node.getNodeName())){ } else if("LI".equalsIgnoreCase(node.getNodeName())){ } else if("FORM".equalsIgnoreCase(node.getNodeName())){ contentScore -= 3; } else if("H1".equalsIgnoreCase(node.getNodeName())){ } else if("H2".equalsIgnoreCase(node.getNodeName())){ } else if("H3".equalsIgnoreCase(node.getNodeName())){ } else if("H4".equalsIgnoreCase(node.getNodeName())){ } else if("H5".equalsIgnoreCase(node.getNodeName())){ } else if("H6".equalsIgnoreCase(node.getNodeName())){ } else if("TH".equalsIgnoreCase(node.getNodeName())){ contentScore -= 5; } // class weight contentScore += getClassWeight(node); fastNode.setContentScore(contentScore); fastNode.setNode(node); return fastNode; } /** * 태그의 id와 class에 근거해 태그의 '무게'를 계산하는 메소드. * @param Element * @return number (Integer) **/ //typeof : number,string,boolean,object,function,undefined private int getClassWeight(Node node) { int weight = 0; /* Look for a special classname */ Node cn = node.getAttributes()!=null?node.getAttributes().getNamedItem("class"):null; String className = ""; if(cn != null) className = cn.getNodeValue(); if (negative.matcher(className).find()) { weight -= 25; } if (positive.matcher(className).find()) { weight += 25; } /* Look for a special ID */ Node in = node.getAttributes()!=null?node.getAttributes().getNamedItem("id"):null; String idName = ""; if(in != null) idName = in.getNodeValue(); if (negative.matcher(idName).find()) { weight -= 25; } if (positive.matcher(idName).find()) { weight += 25; } return weight; } // 파라미터 node의 하위 모든 태그의 이름을 얻는 메소드. private String nodeToString(Node node) { StringBuilder sb = new StringBuilder(); NodeList nodeList = node.getChildNodes(); for (int i = 0; i < nodeList.getLength(); i++) { String nodeName = nodeList.item(i).getNodeName(); if (!"#text".equals(nodeName)) { sb.append(" <"+nodeName); } } return sb.toString(); } /** * htmlcleaner로 html string을 xml string으로 바꿔주는 메소드. * @param source * @return */ private String toXML(String source){ try { CleanerProperties props = new CleanerProperties(); props.setTranslateSpecialEntities(true); props.setOmitComments(true); props.setPruneTags("script,style"); // namespace를 무시한다. props.setNamespacesAware(false); props.setAdvancedXmlEscape(true); props.setTranslateSpecialEntities(true); HtmlCleaner cl = new HtmlCleaner(props); TagNode tagNode = cl.clean(source); source = new PrettyXmlSerializer(props).getXmlAsString(tagNode); } catch (IOException e) { logger.error("",e); } return source; } //test용 public static void main(String[] args) throws IOException { String source = getHTML("http://www.etnews.com/201112020133?mc=m_012_00001"); System.out.println(new ReadabilityExtractor().extract(source)); } //test용 public static String getHTML(String strURL) throws IOException { URL url = new URL(strURL); BufferedReader br = new BufferedReader(new InputStreamReader(url.openStream(),"euc-kr")); String s = ""; StringBuilder sb = new StringBuilder(""); while ((s = br.readLine()) != null) { sb.append(s + "\n"); } return sb.toString(); } }